Skip to content

S06-01 IO流-File

[TOC]

概述

java.io.File 是文件或目录路径名(Pathname)的抽象表示,它并不直接操作文件内部的数据。 简单来说,它就像是一个“地址牌”,指引你找到那个文件或文件夹,至于里面装了什么,那是 InputStream / OutputStream 或者 Reader / Writer 的工作。

跨平台

在使用 File 类时,首先需要面对的就是操作系统的差异(比如 Windows 使用 \ 作为路径分隔符,而 Linux/macOS 使用 /)。

为了写出具备跨平台能力的健壮代码,File 类提供了两个非常重要的静态常量:

  • File.separator:动态获取当前操作系统的路径分隔符

    • Windows: \
    • Linux / macOS: /
  • File.pathSeparator:动态获取当前操作系统的环境变量分隔符(如同一个环境变量中多个路径的分隔)。

    • Windows: ;
    • Linux / macOS: :

💡 最佳实践: 拼接路径时,千万不要硬编码写死斜杠,推荐使用 File.separator 或者直接使用 File 的多参数构造函数。

NIO.2

虽然 File 很经典,但在 Java 7 引入 NIO.2 之后,原生的 File 类暴露出了不少历史包袱和痛点:

  1. 糟糕的错误处理:很多方法(如 delete()createNewFile())在失败时只返回一个 boolean 值(true/false),而不抛出具体的异常(比如拒绝访问、磁盘满、路径未找到),这让排查问题变得很痛苦。

  2. 性能瓶颈:在大数据量或复杂文件系统操作下,它的性能较差,且不支持异步操作。

  3. 功能受限:无法很好地处理符号链接(Symbolic Links)、文件权限(ACL)等高级文件属性。

PathFiles

在现代 Java 开发中,更推荐使用 java.nio.file.Pathjava.nio.file.Files 组合。

  • File 转换为 Path: file.toPath()
  • Path 转换为 File: path.toFile()

现代写法对比:

java
// 旧写法
File file = new File("test.txt");
if(file.exists()) { ... }

// 新写法 (NIO.2)
Path path = Paths.get("test.txt");
if(Files.exists(path)) { ... }

Files 工具类提供了更加强大、语义更清晰、且能正确抛出 IOException 的 API。

文件路径

Java 中的路径主要分为两大阵营绝对路径相对路径),并经历了两代 API(传统 IO 与现代 NIO)的演进。

理解这两者的区别,是避免 FileNotFoundException 的第一步。

绝对路径

  • 定义:从文件系统的根目录(Windows 的盘符如 C:\,Linux/macOS 的 /)开始的完整路径。
  • 特点:唯一、明确。无论你在哪里运行程序,它都能精准找到文件。
  • 示例
    • Windows: C:\Workspace\Project\src\main\resources\config.properties
    • Linux/macOS: /Users/jetty/Workspace/Project/config.properties

相对路径

  • 定义:不从根目录开始,而是相对于当前工作目录的路径。

  • Java 中的基准点:在 Java 中,默认的当前工作目录由系统属性 user.dir 决定。通常情况下:

    • 在 IDE(如 IntelliJ IDEA/Eclipse)中运行

      • 如果使用单元测试方法user.dir 就是程序的模块根目录
      • 如果使用 main() 方法user.dir 就是程序的项目根目录
    • 通过命令行运行 JAR 包user.dir 就是你执行 java -jar 命令时所在的那个文件夹(而不是 JAR 包所在的文件夹)。

  • 特殊符号

    • . :代表当前目录。
    • .. :代表上一级目录。
  • 示例src/main/resources/config.properties

File 路径方法

当你创建了一个 File 对象后,它提供了三个看起来很像、但底层逻辑完全不同的路径获取方法:

java
File file = new File(".\\test.txt"); // 假设当前项目根目录是 C:\Project
方法名作用与返回结果特点
getPath()返回创建该对象时传入的路径字符串。
返回:.\\test.txt
纯粹的字符串复述,你喂它什么,它就吐出什么。
getAbsolutePath()返回文件的绝对路径
返回:C:\Project\.\test.txt
如果传入的是相对路径,它会直接把 user.dir 拼在前面。不会解析 ...
getCanonicalPath()返回规范(唯一)的绝对路径
返回:C:\Project\test.txt
最安全的路径。它会把 ... 解析掉,解析符号链接,并根据系统校正大小写。
注意:该方法会抛出 IOException,因为会访问磁盘。

Path 路径方法

从 Java 7 开始,官方引入了更现代化的 java.nio.file.Path 接口,配合 Paths 工具类使用。它对路径的操作比旧的 File 类更优雅、更安全。

java
import java.nio.file.Path;
import java.nio.file.Paths;

// 1. 创建 Path 对象(支持跨平台拼接)
Path path = Paths.get("src", "main", "resources", "config.properties");

// 2. 获取绝对路径
Path absolutePath = path.toAbsolutePath();

// 3. 规范化路径(相当于 getCanonicalPath(),去掉 . 和 ..)
Path normalizedPath = Paths.get("C:\\Project\\.\\src\\..\\test.txt").normalize();
// 结果: C:\Project\test.txt

// 4. 路径拼接 (Resolve)
Path base = Paths.get("C:\\Project");
Path file = base.resolve("test.txt"); // 结果: C:\Project\test.txt

类路径

在实际开发(尤其是 Spring Boot 或打包部署)中,最容易翻车的就是把文件打进 JAR 包后,传统的 new File("...") 报错找不到文件。

致命误区:一旦文件被打包进 JAR 包里,它就变成了“一串字节码”,不再是操作系统文件系统里的一个独立文件了。此时,java.io.File 无法直接指向 JAR 包内部的路径。

正确解法使用类加载器 (ClassLoader) 获取路径

如果要读取项目 resources 目录下的文件,应该使用类路径读取:

java
// 方式 1:获取流(最推荐!打成 JAR 包后依然稳健)
InputStream is = MyClass.class.getClassLoader().getResourceAsStream("config.properties");

// 方式 2:获取类路径下的 URL
URL url = MyClass.class.getClassLoader().getResource("config.properties");
String pathStr = url.getPath();

注意getResource("") 传入空字符串时,代表的正是 target/classes/(编译后的类路径根目录)。

最佳实践

  1. 跨平台分隔符:手动拼接字符串路径时,不要硬编码 /\,请使用 File.separatorPaths.get()

  2. 判断真实路径:对比两个路径是否指向同一个文件,不要用 path1.equals(path2),而应该使用 Files.isSameFile(path1, path2)

  3. 资源文件读取:只要是随项目一起打包的配置文件、模板、图片,一律使用 getResourceAsStream(),不要用 new File()

API:File

构造方法

  • File File()(String pathname)构造方法。将指定的路径名字符串转换为抽象路径名来创建新的 File 实例。

  • File File()(String parent, String child)构造方法。根据父路径名字符串和子路径名字符串拼接创建新的 File 实例。

  • File File()(File parent, String child)构造方法。根据已有的父抽象路径名对象和子路径名字符串创建新的 File 实例。

  • File File()(URI uri)构造方法。通过将给定的 file: 协议的 URI 转换为抽象路径名来创建新的 File 实例。

注意事项

  1. 执行构造方法时,Java 虚拟机不会去校验该路径在磁盘上是否存在。它仅仅是一个纯粹的内存对象。
  2. 传入的 URI 参数必须满足绝对、分层等规范,且其 scheme 必须为 "file",否则会在运行时抛出 IllegalArgumentException
java
import java.io.File;
import java.net.URI;

public class FileConstructorDemo {
  public static void main(String[] args) throws Exception {
    // 1. 基于单一路径字符串创建
    File file1 = new File("C:/demo/test.txt");

    // 2. 基于父路径字符串与子路径字符串创建
    File file2 = new File("C:/demo", "test.txt");

    // 3. 基于父路径对象与子路径字符串创建
    File parentDir = new File("C:/demo");
    File file3 = new File(parentDir, "test.txt");

    // 4. 基于 URI 实例化
    URI uri = new URI("file:///C:/demo/test.txt");
    File file4 = new File(uri);

    System.out.println("file1 path: " + file1.getPath());
    System.out.println("file2 path: " + file2.getPath());
    System.out.println("file3 path: " + file3.getPath());
    System.out.println("file4 path: " + file4.getPath());
  }
}

路径与名称获取

  • String getName()()获取最终节点名称。返回由此抽象路径名表示的文件或目录的最后一个名称(即除去父路径后的部分)。

  • String getPath()()获取构造路径。将此抽象路径名转换为路径名字符串,返回的是定义时传入的原始路径表达式。

  • String getAbsolutePath()()获取绝对路径字符串。返回此抽象路径名的绝对路径名字符串。

  • File getAbsoluteFile()()获取绝对路径对象。返回此抽象路径名的绝对路径名形式的 File 对象。

  • String getCanonicalPath()()获取规范路径字符串。返回此抽象路径名的唯一规范路径名字符串。会自动解析路径中的相对引用 ... 以及符号链接。

  • File getCanonicalFile()()获取规范路径对象。返回此抽象路径名的规范形式的 File 对象。

  • String getParent()()获取父目录字符串。返回此抽象路径名父目录的路径名字符串;如果此路径名没有指定父目录,则返回 null

  • File getParentFile()()获取父目录对象。返回此抽象路径名父目录的 File 对象;如果此路径名没有指定父目录,则返回 null

注意事项

  1. getAbsolutePath() 仅仅执行了简单的字符串拼接(user.dir + 相对路径),并不会消除路径中多余的 ... 字符。
  2. getCanonicalPath() 在消除相对符号时会访问物理文件系统,因此它会抛出 IOException 检查型异常。如果遇到循环软链接等系统异常情况,可能会引发性能或逻辑问题。
java
import java.io.File;
import java.io.IOException;

public class FilePathDemo {
  public static void main(String[] args) {
    // 使用包含相对路径符号的表达式初始化
    File file = new File("./src/../logs/app.log");

    System.out.println("File Name: " + file.getName());
    System.out.println("Defined Path: " + file.getPath());
    System.out.println("Absolute Path: " + file.getAbsolutePath());

    try {
      // 规范路径会解析掉其中的 . 和 ..
      System.out.println("Canonical Path: " + file.getCanonicalPath());
    } catch (IOException e) {
      System.err.println("解析规范路径失败: " + e.getMessage());
    }

    System.out.println("Parent Path String: " + file.getParent());
  }
}

文件属性与状态检查

  • boolean exists()()检查物理存在性。测试此抽象路径名表示的文件或目录在文件系统中是否真实存在。

  • boolean isDirectory()()判断是否为目录。测试此抽象路径名表示的文件是否是一个目录。

  • boolean isFile()()判断是否为常规文件。测试此抽象路径名表示的文件是否是一个标准、常规的文件。

  • boolean isHidden()()判断是否为隐藏节点。测试此抽象路径名指定的文件在当前操作系统中是否被标记为隐藏文件。

  • boolean canRead()()检查可读权限。测试当前的 Java 虚拟机进程是否具备读取此抽象路径名所表示文件的权限。

  • boolean canWrite()()检查可写权限。测试当前的 Java 虚拟机进程是否具备修改或写入此抽象路径名所表示文件的权限。

  • long lastModified()()获取最后修改时间。返回此文件最后一次被修改的时间戳(自 1970-01-01 00:00:00 GMT 起的毫秒数)。若文件不存在则返回 0L

  • long length()()获取文件长度。返回由此抽象路径名表示的文件的字节大小。如果此路径表示一个目录,则返回值是不确定的(通常在有些系统上为 0 或 4096 字节)。

注意事项

  1. 当路径在物理磁盘上不存在时,isDirectory()canRead()canWrite()isFile() 会默认直接返回 false,而不会引发异常。因此,在调用二者之前,最好先通过 exists() 进行前置校验。
  2. 对正在被其他并发进程写入的文件调用 length(),其返回值取决于操作系统的文件系统刷新机制,获取到的数据可能存在暂时的延迟。
  3. canRead()canWrite() 的返回结果完全取决于运行当前 JVM 的操作系统用户权限。例如,某个文件对系统管理员(Root/Administrator)是可读写的,但如果 JVM 是以低权限普通用户身份启动的,该方法依然会返回 false
  4. 在 Windows 平台上,canWrite() 核心依赖于文件的“只读”属性标记;而在 Unix/Linux 平台上,它则严格遵循 POSIX 文件权限体系(rwx)中针对当前执行用户的写权限位设定。
java
import java.io.File;
import java.util.Date;

public class FileAttributeDemo {
  public static void main(String[] args) {
    // 绑定一个当前工作目录下的真实或虚拟路径
    File target = new File("src");

    if (target.exists()) {
      System.out.println("路径是否存在: " + target.exists());
      System.out.println("是否为目录: " + target.isDirectory());
      System.out.println("是否为常规文件: " + target.isFile());
      System.out.println("是否为隐藏属性: " + target.isHidden());
      System.out.println("是否可读: " + target.canRead());
      System.out.println("是否可写: " + target.canWrite());
      System.out.println("最后修改时间: " + new Date(target.lastModified()));
      System.out.println("文件/目录大小: " + target.length() + " 字节");
    } else {
      System.out.println("指定的目标路径在文件系统中不存在。");
    }
  }
}

文件与目录操作

  • boolean createNewFile()()原子创建新文件。当且仅当具有该名称的文件尚不存在时,原子地创建一个新的空物理文件(会抛出 IOException)。

  • File File.createTempFile()(String prefix, String suffix)创建临时文件。在系统默认的临时文件目录中创建一个空文件,使用指定的前缀和后缀生成文件名。

  • boolean mkdir()()创建单级目录。创建此抽象路径名指定的物理目录。如果其父目录不存在,则创建失败并返回 false

  • boolean mkdirs()()创建多级目录:创建此抽象路径名指定的目录,包括所有必需但不存在的父目录。

  • boolean delete()()物理永久删除。删除此抽象路径名表示的文件或目录。删除目录时必须为空目录

  • void deleteOnExit()()延迟注销删除。注册该抽象路径,在 Java 虚拟机安全退出(正常终止)时,自动请求删除此文件或目录。

  • boolean renameTo()(File dest)重命名或剪切移动。将当前文件或目录重新命名,或将其移动至目标 File 实例所指定的物理路径。

注意事项

  1. delete() 方法在删除一个目录节点时,要求该目录必须为空目录(即该目录下不能含有任何子文件或子目录),否则会直接返回 false
  2. renameTo() 的成功与否极大程度取决于底层的操作系统实现。跨文件系统卷轴(如从 Windows 的 C 盘直接剪切移动到 D 盘)的操作极易失败并返回 false
java
import java.io.File;
import java.io.IOException;

public class FileOperationDemo {
  public static void main(String[] args) {
    // 1. 创建多级目录
    File subDir = new File("demoDir/level1/level2");
    if (subDir.mkdirs()) {
      System.out.println("多级目录创建成功");
    }

    // 2. 在指定目录下创建空文件
    File targetFile = new File(subDir, "source.txt");
    try {
      if (targetFile.createNewFile()) {
        System.out.println("新文件创建成功: " + targetFile.getAbsolutePath());
      }
    } catch (IOException e) {
      System.err.println("创建文件异常: " + e.getMessage());
    }

    // 3. 文件的重命名/移动
    File destFile = new File("demoDir/moved_target.txt");
    if (targetFile.renameTo(destFile)) {
      System.out.println("文件成功重命名/移动至: " + destFile.getPath());
    }

    // 4. 清理创建的文件(注意:级联目录需要由内向外逐个调用 delete 才能清空)
    if (destFile.delete()) {
      System.out.println("目标物理文件已成功清理");
    }
  }
}

目录遍历

  • String[] list()()列出子级名称数组。返回一个字符串数组,包含此抽象路径名表示的目录中的所有子文件和子目录的名称。

  • String[] list()(FilenameFilter filter)条件过滤列出名称。返回一个字符串数组,包含此目录中满足指定过滤器条件的子文件和子目录的名称。

  • File[] listFiles()()列出子级路径对象数组。返回一个 File 对象数组,表示此抽象路径名表示的目录中的所有子文件和子目录。

  • File[] listFiles()(FileFilter filter)条件过滤列出对象。返回一个 File 对象数组,包含此目录中满足指定 FileFilter 过滤器的子文件和子目录。

  • File[] listFiles()(FilenameFilter filter)条件过滤列出对象。返回一个 File 对象数组,包含此目录中满足指定 FilenameFilter 过滤器的子文件和子目录。

注意事项

  1. 如果对一个非目录(普通文件)或在文件系统中不存在的路径调用 list()listFiles(),方法将直接返回 null。此时如果未进行判空就直接使用 for-each 遍历该返回值,会抛出经典的 NullPointerException
  2. 返回的数组内容不保证具备任何特定名称或时间顺序,如果业务上依赖排序,需要手动使用 Arrays.sort()
java
import java.io.File;
import java.io.FilenameFilter;

public class FileTraversalDemo {
  public static void main(String[] args) {
    File currentDir = new File(".");

    // 1. 使用 FilenameFilter 过滤出当前目录下所有以 ".xml" 或 ".json" 结尾的配置文件
    File[] configFiles = currentDir.listFiles(new FilenameFilter() {
      @Override
      public boolean accept(File dir, String name) {
        String lowerName = name.toLowerCase();
        return lowerName.endsWith(".xml") || lowerName.endsWith(".json");
      }
    });

    // 2. 严格执行判空逻辑,防止在目标不是目录时崩溃
    if (configFiles != null) {
      System.out.println("过滤匹配到的配置文件数量: " + configFiles.length);
      for (File file : configFiles) {
        System.out.println("配置文件名: " + file.getName() + " | 大小: " + file.length() + " 字节");
      }
    } else {
      System.out.println("指定的路径不是一个有效的目录。");
    }
  }
}

实战案例

实战:递归遍历文件夹

下面是一个经典的面试与实战场景:使用 File 类递归打印出某个文件夹下的所有文件。

java
import java.io.File;

public class FileTraversalDemo {
  public static void main(String[] args) {
    // 假设要遍历当前项目下的一个叫做 "my_folder" 的目录
    File dir = new File("my_folder");

    if (dir.exists() && dir.isDirectory()) {
      getAllFiles(dir, 0);
    } else {
      System.out.println("目标目录不存在或不是一个文件夹!");
    }
  }

  private static void getAllFiles(File dir, int level) {
    // 1. 打印缩进,让层级结构更清晰
    StringBuilder indent = new StringBuilder();
    for (int i = 0; i < level; i++) {
      indent.append("  ");
    }

    // 2. 获取当前目录下的所有子文件和子目录
    File[] files = dir.listFiles();

    if (files != null) {
      for (File file : files) {
        if (file.isDirectory()) {
          System.out.println(indent + "📁 " + file.getName());
          // 3. 如果是目录,继续递归调用
          getAllFiles(file, level + 1);
        } else {
          System.out.println(indent + "📄 " + file.getName() + " (" + file.length() + " bytes)");
        }
      }
    }
  }
}

实战:递归删除目录

在 Java 中,File.delete() 方法具有局限性:它只能删除空目录或单个文件。如果目标目录中包含任何子文件或子目录,该方法将直接返回 false 且不报错。因此,要彻底删除一个非空目录,必须通过深度优先遍历的方式,先递归删除其内部的所有子节点,最后再删除目录本身。

注意事项

  1. 操作不可逆File.delete() 是直接从物理磁盘上抹除数据,不会进入操作系统的回收站,执行前必须严格校验目标路径,防止误删系统关键目录。
  2. 异常终止风险:如果在遍历过程中,某个子文件因为被其他进程独占锁定而导致 delete() 失败,递归流程会继续,但最终的父目录会因为“非空”而导致删除失败。
java
import java.io.File;

public class FileRecursiveDeleteDemo {
  public static void main(String[] args) {
    // 绑定需要删除的目标目录
    File targetDir = new File("./temp_workspace");

    // 模拟环境:先创建一些用于测试的文件
    setupMockEnvironment(targetDir);

    System.out.println("开始递归删除目录: " + targetDir.getAbsolutePath());
    boolean success = deleteDirectory(targetDir);
    System.out.println("目录删除最终结果: " + (success ? "成功" : "失败"));
  }

  /**
   * 递归删除目录及其所有子项
   *
   * @param directory 要删除的目录或文件
   * @return 是否全部删除成功
   */
  public static boolean deleteDirectory(File directory) {
    if (!directory.exists()) {
      return true;
    }

    if (directory.isDirectory()) {
      File[] files = directory.listFiles();
      // 必须严格判空,防止因权限不足导致 files 为 null 触发空指针异常
      if (files != null) {
        for (File file : files) {
          // 递归调用删除子文件或子目录
          if (!deleteDirectory(file)) {
            return false;
          }
        }
      }
    }

    // 此时目录内部已被清空,或者当前节点本身就是文件,可以直接执行原子删除
    System.out.println("正在物理删除节点: " + directory.getAbsolutePath());
    return directory.delete();
  }

  private static void setupMockEnvironment(File baseDir) {
    try {
      File subDir = new File(baseDir, "sub_folder");
      subDir.mkdirs();
      new File(baseDir, "root_file.txt").createNewFile();
      new File(subDir, "child_file.log").createNewFile();
      System.out.println("[系统提示] 用于测试的非空目录初始化完毕。");
    } catch (Exception e) {
      System.err.println("初始化模拟环境失败: " + e.getMessage());
    }
  }
}

实战:递归计算目录大小

对代表目录的 File 对象直接调用 length() 方法时,其返回值取决于底层的操作系统与文件系统类型。在很多平台上,它仅仅返回该目录元数据占用的固定块大小(如 0 或 4096 字节),而非其内部所有文件的实际大小总和。要获取一个目录的真实物理大小,必须递归累加该目录下所有普通文件的 length() 值。

注意事项

  1. 数值溢出隐患:大型目录的总字节数极易超出 Integer.MAX_VALUE(约 2GB),因此用于累加大小的变量必须声明为 long 类型。
  2. 符号链接(软链接):如果目录中包含指向外部大文件的符号链接,File.length() 可能会统计该链接指向的实际文件大小,在复杂文件系统中若存在循环链接,传统的单纯递归可能会引发 StackOverflowError
java
import java.io.File;

public class FileSizeCalculatorDemo {
  public static void main(String[] args) {
    // 绑定需要统计大小的目标目录(此处以当前项目根目录为例)
    File projectDir = new File(".");

    System.out.println("开始计算目标路径空间占用: " + projectDir.getAbsolutePath());
    long totalSize = calculateDirectorySize(projectDir);

    System.out.println("计算完成!");
    System.out.println("总大小 (字节): " + totalSize + " Bytes");
    System.out.println("总大小 (KB): " + String.format("%.2f", totalSize / 1024.0) + " KB");
    System.out.println("总大小 (MB): " + String.format("%.2f", totalSize / (1024.0 * 1024.0)) + " MB");
  }

  /**
   * 递归计算文件或目录的总大小
   *
   * @param node 当前判断的文件或目录节点
   * @return 累计的字节大小 (Bytes)
   */
  public static long calculateDirectorySize(File node) {
    // 若节点不存在,直接返回 0
    if (!node.exists()) {
      return 0L;
    }

    // 如果是常规文件,直接返回其 length 大小
    if (node.isFile()) {
      return node.length();
    }

    // 如果是目录,则需要遍历累加所有子节点的大小
    long sum = 0L;
    if (node.isDirectory()) {
      File[] subNodes = node.listFiles();
      // 严格执行安全判空,防止目录无访问权限时程序崩溃
      if (subNodes != null) {
        for (File child : subNodes) {
          sum += calculateDirectorySize(child);
        }
      }
    }
    return sum;
  }
}